feat(stovepipe): record the build outcome on the request and free its slot - #467
Merged
Merged
Conversation
This was referenced Jul 29, 2026
behinddwalls
commented
Jul 29, 2026
behinddwalls
marked this pull request as ready for review
July 29, 2026 23:09
behinddwalls
force-pushed
the
preetam/stovepipe-buildsignal-outcome
branch
from
July 30, 2026 07:51
3e8588f to
8db35e8
Compare
mnoah1
approved these changes
Jul 30, 2026
behinddwalls
force-pushed
the
preetam/stovepipe-buildsignal-outcome
branch
from
July 30, 2026 18:20
8db35e8 to
1771172
Compare
github-merge-queue
Bot
removed this pull request from the merge queue because the pull requests in the stack were not ordered correctly in the queue
Jul 30, 2026
behinddwalls
force-pushed
the
preetam/stovepipe-buildsignal-outcome
branch
from
July 30, 2026 18:31
1771172 to
3197ff9
Compare
behinddwalls
force-pushed
the
preetam/stovepipe-buildsignal-outcome
branch
from
July 30, 2026 19:57
3197ff9 to
830f896
Compare
… slot ## Summary ### Why? Nothing wrote the build outcome states the previous commit introduced, and nothing released a request's build slot on the normal path. The slot was specified as `record`'s to release, but `record` does not exist — so today every admitted request holds its slot forever and only the DLQ reconciler ever frees one. Releasing it in `record` is also the wrong home. The gate `process` claims is a *build* slot: it bounds concurrent builds per Queue, and once the build reaches a terminal status the build is over. Waiting for a later stage to notice keeps the gate closed for no reason, and it breaks a useful invariant — *a terminal request has already released its slot* — which is exactly what makes the DLQ reconciler's early-return on terminal requests safe. ### What? When a build reaches a terminal status, `buildsignal` now releases the queue's build slot and CAS-transitions the request from `processing` to the matching outcome, then publishes to `record` as before. The transition is first-writer-wins, so the duplicate builds that at-least-once delivery can produce cannot flip a request's verdict back and forth. Two ordering rules, both serving one invariant — *the request must not go terminal while still holding a slot*, because a terminal request is skipped by redelivery and by the reconciler alike, so nothing would ever decrement it: - the slot is released *before* the terminal write, and - a failed release aborts the write. Failing this way leaves the request non-terminal, so redelivery re-runs both steps and decrements twice — transiently over-admitting by one until the zero clamp reconverges. That is the same trade the DLQ reconciler already documents and makes, for the same reason. The early-exit guard also narrows. With outcomes now terminal, the old `IsTerminal()` check would return before republishing to `record`, losing the message if the process died between the stamp and the publish. The guard now proceeds when the request is `processing` or already carries an outcome; falling through in the second case is safe, since the re-poll is redundant but harmless, the status and outcome writes both no-op, and the slot is not released twice. It is phrased that way rather than as "skip if superseded" because a superseded request cannot reach the poll loop at all — `process` supersedes solely from `accepted`, and a build only exists once the request was admitted. Naming an unreachable state would have implied a case that does not exist. The guard is still kept rather than dropped: the slot release is conditioned on not already having an outcome, so a terminal-without-an-outcome request arriving here would decrement a slot that superseding never claimed. This also fixes a stale RFC sentence in `process.md` that still described the slot as released by `record` writing green or not-green. ## Test Plan ✅ `bazel test //stovepipe/...` — outcome stamped for each terminal status; the slot released exactly once and *not* on a redelivery of an already-stamped request; a failed release leaves the request non-terminal with no record publish; write-once still holds when a later poll disagrees. ✅ `bazel test //test/e2e/stovepipe/...` — the pipeline runs end to end.
behinddwalls
force-pushed
the
preetam/stovepipe-buildsignal-outcome
branch
from
July 30, 2026 20:09
830f896 to
92e1775
Compare
behinddwalls
added a commit
that referenced
this pull request
Jul 30, 2026
## Summary ### Why? PR #443 disabled this workflow outright (`if: false`) when the repo moved to GitHub's native stacked PRs. That was too blunt: native stacks and hand-rolled `arh` chains coexist here today. Stacks #470, #472 and #474 are native, while #460 → #461 → #462 → #463 → #476 is a plain chain whose every member reports `stack: null`. With the workflow off, merging any PR in a hand-rolled chain leaves the children still carrying their parent's commits — precisely the broken-diff problem this workflow was written to fix. GitHub owns the lifecycle of its own stacks: on a partial merge it rebases and retargets the surviving members itself, so the workflow must not touch those. It only needs to tell the two apart rather than give up on both. ### What? Re-enables the job and skips only the PRs GitHub actually owns, instead of switching the whole workflow off. A `detect_stack` helper resolves native-stack membership from `GET /repos/{owner}/{repo}/pulls/{n}`. It queries the REST API at run time rather than reading `github.event.pull_request.stack` off the webhook payload, so a PR added to a stack after the merge event was queued is still recognised. Membership survives merge — a merged member still reports its stack — which is what makes the lookup meaningful at this point in the lifecycle. The check is applied **per child PR**, not to the merged PR. GitHub only ever rebases a stack's own members; a PR that targets a member's head branch without joining the stack is invisible to that machinery. Skipping the whole chain whenever the merged PR happened to be a stack member would therefore strand such a PR with exactly the broken diff this workflow exists to prevent. `rebase_chain` always runs; each child that is a stack member is skipped and not recursed into, while its siblings rebase normally. Membership of the merged PR is logged for context and gates nothing. Presence is tested on the `stack` object itself rather than on `stack.number`. Probing a sub-field means a stack object arriving without that field reads as "standalone" and gets force-pushed — the unsafe direction to fail in. The number is used only for logging. An unreadable response still falls back to standalone, since the Stacks API 404s when the feature is not enabled for a repo, which is exactly when the rebase is wanted. `cleanup_orphaned_merged_branches` still runs on every merge, including those where every child turned out to be GitHub's to rebase. This workflow therefore remains the sole owner of head-branch deletion, and native-stack head branches are reaped here too, each on the first merge after GitHub has retargeted its successors off it. This depends on "Automatically delete head branches" being OFF, as the header has always documented — otherwise GitHub retargets children to `main` before the job runs and the non-native path silently no-ops on a green job. The setting has been turned off on the repo. ## Test Plan ✅ YAML parses; job `if` and step `env` verified after the edit. ✅ `bash -n` clean on the extracted `run:` block under bash 5.2, the version Actions runners use. Note for future edits: macOS `/bin/bash` 3.2 reports a spurious `syntax error near ';;'` on this script — it cannot parse the heredoc nested in `$( )`, so it blames a line far from the real construct. ✅ `actionlint` v1.7.7 — clean. ✅ `zizmor` v1.25.2 (`--no-online-audits`, the version CI pins) — no findings, 2 ignored and 5 suppressed, confirming the existing `.github/zizmor.yml` exceptions still cover the file. ✅ `yamlfmt` v0.16.0 `-lint` — clean. ✅ `detect_stack` exercised against live PRs in this repo: | PR | `.stack` | Result | | --- | --- | --- | | #467 | stack #470, position 4/6, merged | native → skipped | | #468 | stack #470, position 5/6, open | native → skipped | | #404 | stack #472 | native → skipped | | #457 | stack #475, size 1 | native → skipped | | #443 | `null` | standalone → rebased | | #460 | `null` | standalone → rebased | | #999999 | HTTP 404 | warns, treated as standalone, no `set -euo pipefail` abort | ✅ The sub-field trap verified directly: a synthetic `{"stack":{"id":51488,"position":4,"size":6}}` with no `number` reads as standalone under `.stack.number // empty`, and as in-a-stack under the expression shipped here. ✅ Observed reference case — the merge of #467 (position 4 of stack #470). GitHub retargeted #468 from `preetam/stovepipe-buildsignal-outcome` to `main` and left #469 on #468's branch, confirming both that GitHub handles its own members and that a merged member retains its `stack` object. The rebase itself can only be exercised post-merge. On the next merge of a hand-rolled chain (the runway series is the live case) watch for `=== Stack rebase complete ===` and a child retargeted with only its own commits; on a native-stack merge watch for each member child logging `skipped: belongs to native stack #N`, followed by the branch sweep. The first sweep will also reap `preetam/stovepipe-buildsignal-outcome`, which is currently orphaned — it merged after auto-delete was turned off and has no open dependents.
behinddwalls
added a commit
that referenced
this pull request
Jul 30, 2026
## Summary ### Why? PR #443 disabled this workflow outright (`if: false`) when the repo moved to GitHub's native stacked PRs. That was too blunt: native stacks and hand-rolled `arh` chains coexist here today. Stacks #470, #472 and #474 are native, while #460 → #461 → #462 → #463 → #476 is a plain chain whose every member reports `stack: null`. With the workflow off, merging any PR in a hand-rolled chain leaves the children still carrying their parent's commits — precisely the broken-diff problem this workflow was written to fix. GitHub owns the lifecycle of its own stacks: on a partial merge it rebases and retargets the surviving members itself, so the workflow must not touch those. It only needs to tell the two apart rather than give up on both. ### What? Re-enables the job and skips only the PRs GitHub actually owns, instead of switching the whole workflow off. A `detect_stack` helper resolves native-stack membership from `GET /repos/{owner}/{repo}/pulls/{n}`. It queries the REST API at run time rather than reading `github.event.pull_request.stack` off the webhook payload, so a PR added to a stack after the merge event was queued is still recognised. Membership survives merge — a merged member still reports its stack — which is what makes the lookup meaningful at this point in the lifecycle. The check is applied **per child PR**, not to the merged PR. GitHub only ever rebases a stack's own members; a PR that targets a member's head branch without joining the stack is invisible to that machinery. Skipping the whole chain whenever the merged PR happened to be a stack member would therefore strand such a PR with exactly the broken diff this workflow exists to prevent. `rebase_chain` always runs; each child that is a stack member is skipped and not recursed into, while its siblings rebase normally. Membership of the merged PR is logged for context and gates nothing. Presence is tested on the `stack` object itself rather than on `stack.number`. Probing a sub-field means a stack object arriving without that field reads as "standalone" and gets force-pushed — the unsafe direction to fail in. The number is used only for logging. An unreadable response still falls back to standalone, since the Stacks API 404s when the feature is not enabled for a repo, which is exactly when the rebase is wanted. `cleanup_orphaned_merged_branches` still runs on every merge, including those where every child turned out to be GitHub's to rebase. This workflow therefore remains the sole owner of head-branch deletion, and native-stack head branches are reaped here too, each on the first merge after GitHub has retargeted its successors off it. This depends on "Automatically delete head branches" being OFF, as the header has always documented — otherwise GitHub retargets children to `main` before the job runs and the non-native path silently no-ops on a green job. The setting has been turned off on the repo. ## Test Plan ✅ YAML parses; job `if` and step `env` verified after the edit. ✅ `bash -n` clean on the extracted `run:` block under bash 5.2, the version Actions runners use. Note for future edits: macOS `/bin/bash` 3.2 reports a spurious `syntax error near ';;'` on this script — it cannot parse the heredoc nested in `$( )`, so it blames a line far from the real construct. ✅ `actionlint` v1.7.7 — clean. ✅ `zizmor` v1.25.2 (`--no-online-audits`, the version CI pins) — no findings, 2 ignored and 5 suppressed, confirming the existing `.github/zizmor.yml` exceptions still cover the file. ✅ `yamlfmt` v0.16.0 `-lint` — clean. ✅ `detect_stack` exercised against live PRs in this repo: | PR | `.stack` | Result | | --- | --- | --- | | #467 | stack #470, position 4/6, merged | native → skipped | | #468 | stack #470, position 5/6, open | native → skipped | | #404 | stack #472 | native → skipped | | #457 | stack #475, size 1 | native → skipped | | #443 | `null` | standalone → rebased | | #460 | `null` | standalone → rebased | | #999999 | HTTP 404 | warns, treated as standalone, no `set -euo pipefail` abort | ✅ The sub-field trap verified directly: a synthetic `{"stack":{"id":51488,"position":4,"size":6}}` with no `number` reads as standalone under `.stack.number // empty`, and as in-a-stack under the expression shipped here. ✅ Observed reference case — the merge of #467 (position 4 of stack #470). GitHub retargeted #468 from `preetam/stovepipe-buildsignal-outcome` to `main` and left #469 on #468's branch, confirming both that GitHub handles its own members and that a merged member retains its `stack` object. The rebase itself can only be exercised post-merge. On the next merge of a hand-rolled chain (the runway series is the live case) watch for `=== Stack rebase complete ===` and a child retargeted with only its own commits; on a native-stack merge watch for each member child logging `skipped: belongs to native stack #N`, followed by the branch sweep. The first sweep will also reap `preetam/stovepipe-buildsignal-outcome`, which is currently orphaned — it merged after auto-delete was turned off and has no open dependents.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Why?
Nothing wrote the build outcome states the previous commit introduced, and nothing released a request's build slot on the normal path. The slot was specified as
record's to release, butrecorddoes not exist — so today every admitted request holds its slot forever and only the DLQ reconciler ever frees one.Releasing it in
recordis also the wrong home. The gateprocessclaims is a build slot: it bounds concurrent builds per Queue, and once the build reaches a terminal status the build is over. Waiting for a later stage to notice keeps the gate closed for no reason, and it breaks a useful invariant — a terminal request has already released its slot — which is exactly what makes the DLQ reconciler's early-return on terminal requests safe.What?
When a build reaches a terminal status,
buildsignalnow releases the queue's build slot and CAS-transitions the request fromprocessingto the matching outcome, then publishes torecordas before. The transition is first-writer-wins, so the duplicate builds that at-least-once delivery can produce cannot flip a request's verdict back and forth.Two ordering rules, both serving one invariant — the request must not go terminal while still holding a slot, because a terminal request is skipped by redelivery and by the reconciler alike, so nothing would ever decrement it:
Failing this way leaves the request non-terminal, so redelivery re-runs both steps and decrements twice — transiently over-admitting by one until the zero clamp reconverges. That is the same trade the DLQ reconciler already documents and makes, for the same reason.
The early-exit guard also narrows. With outcomes now terminal, the old
IsTerminal()check would return before republishing torecord, losing the message if the process died between the stamp and the publish. The guard now proceeds when the request isprocessingor already carries an outcome; falling through in the second case is safe, since the re-poll is redundant but harmless, the status and outcome writes both no-op, and the slot is not released twice.It is phrased that way rather than as "skip if superseded" because a superseded request cannot reach the poll loop at all —
processsupersedes solely fromaccepted, and a build only exists once the request was admitted. Naming an unreachable state would have implied a case that does not exist. The guard is still kept rather than dropped: the slot release is conditioned on not already having an outcome, so a terminal-without-an-outcome request arriving here would decrement a slot that superseding never claimed.This also fixes a stale RFC sentence in
process.mdthat still described the slot as released byrecordwriting green or not-green.Test Plan
✅
bazel test //stovepipe/...— outcome stamped for each terminal status; the slot released exactly once and not on a redelivery of an already-stamped request; a failed release leaves the request non-terminal with no record publish; write-once still holds when a later poll disagrees.✅
bazel test //test/e2e/stovepipe/...— the pipeline runs end to end.Stack